Skip to content

Conversation

@richardshiue
Copy link
Contributor

@richardshiue richardshiue commented Dec 12, 2025

Description


Checklist

General

  • I've included relevant documentation or comments for the changes introduced.
  • I've tested the changes in multiple environments (e.g., different browsers, operating systems).

Testing

  • I've added or updated tests to validate the changes introduced for AppFlowy Web.

Feature-Specific

  • For feature additions, I've added a preview (video, screenshot, or demo) in the "Feature Preview" section.
  • I've verified that this feature integrates seamlessly with existing functionality.

Summary by Sourcery

Add a document version history experience accessible from the page more-actions menu, including a modal with a read-only editor preview and timeline of versions backed by new collaboration history APIs.

New Features:

  • Expose a version history action in the document more-actions menu that opens a dedicated document history modal.
  • Introduce a document history modal with a timeline of versions, filters, and restore controls plus a read-only preview of the selected version.
  • Add server-backed APIs and client wiring to fetch collaboration history, preview specific versions, and revert to a chosen version.

Bug Fixes:

  • Fix view type detection in useViewOperations when checking for database and document sections in Yjs documents.

Enhancements:

  • Adjust button and dropdown menu styling for better spacing and borders in menus.
  • Extend app and business context types and handlers to surface collaboration history and revert capabilities throughout the app.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 12, 2025

Reviewer's Guide

Implements a document version history modal for document views, wiring it through the More Actions menu, exposing collab history APIs through the app context, and adjusting some shared UI primitives to support the new UX.

Sequence diagram for opening document version history and restoring a version

sequenceDiagram
    actor User
    participant MoreActions as MoreActions
    participant MoreActionsContent as MoreActionsContent
    participant DocumentHistoryModal as DocumentHistoryModal
    participant AppHandlers as useAppHandlers
    participant AFClientService as AFClientService
    participant HttpAPI as http_api_getCollabVersions
    participant SyncContext as SyncInternal_revert

    User->>MoreActions: click_more_actions_button
    MoreActions->>MoreActions: setOpen(true)
    MoreActions->>MoreActionsContent: render_with(viewId, onOpenHistory)

    User->>MoreActionsContent: select_VersionHistory_item
    MoreActionsContent->>MoreActions: onOpenHistory()
    MoreActions->>MoreActions: handleOpenHistory()
    MoreActions->>MoreActions: setOpen(false)
    MoreActions->>MoreActions: setHistoryOpen(true)
    MoreActions->>DocumentHistoryModal: render(open=true, viewId)

    DocumentHistoryModal->>AppHandlers: getCollabHistory(viewId)
    AppHandlers->>AFClientService: getCollabHistory(workspaceId, viewId, since)
    AFClientService->>HttpAPI: getCollabVersions(workspaceId, viewId, since)
    HttpAPI-->>AFClientService: CollabVersionRecord[]
    AFClientService-->>AppHandlers: CollabVersionRecord[]
    AppHandlers-->>DocumentHistoryModal: CollabVersionRecord[]
    DocumentHistoryModal->>DocumentHistoryModal: setVersions()
    DocumentHistoryModal->>DocumentHistoryModal: setSelectedVersionId(firstVersion)

    User->>DocumentHistoryModal: select_version_in_list
    DocumentHistoryModal->>DocumentHistoryModal: setSelectedVersionId(versionId)

    User->>DocumentHistoryModal: click_Restore
    DocumentHistoryModal->>AppHandlers: revertCollabVersion(viewId, versionId)
    AppHandlers->>SyncContext: revertCollabVersion(viewId, versionId)
    SyncContext-->>AppHandlers: Promise_resolved
    AppHandlers-->>DocumentHistoryModal: Promise_resolved

    DocumentHistoryModal->>DocumentHistoryModal: clear_preview_cache
    DocumentHistoryModal->>AppHandlers: getCollabHistory(viewId)
    AppHandlers->>AFClientService: getCollabHistory(workspaceId, viewId, since)
    AFClientService->>HttpAPI: getCollabVersions(...)
    HttpAPI-->>AFClientService: CollabVersionRecord[]
    AFClientService-->>AppHandlers: CollabVersionRecord[]
    AppHandlers-->>DocumentHistoryModal: CollabVersionRecord[]
    DocumentHistoryModal->>DocumentHistoryModal: update_versions_and_selection

    User->>DocumentHistoryModal: close_modal
    DocumentHistoryModal->>MoreActions: onOpenChange(false)
    MoreActions->>MoreActions: setHistoryOpen(false)
Loading

Updated class diagram for collab history services and document history UI

classDiagram
    class CollabVersionRecord {
      +string versionId
      +string viewId
      +string parentId
      +string name
      +Date createdAt
      +boolean isDeleted
      +number[] editors
    }

    class EncodedCollab {
      +Uint8Array stateVector
      +Uint8Array docState
      +string version
    }

    class CollabHistoryService {
      <<interface>>
      +getCollabHistory(workspaceId: string, viewId: string, since: Date) Promise~CollabVersionRecord[]~
      +previewCollabVersion(workspaceId: string, viewId: string, versionId: string, collabType: Types) Promise~Uint8Array~
      +createCollabVersion(workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) Promise~string~
      +deleteCollabVersion(workspaceId: string, viewId: string, versionId: string) Promise~void~
      +revertCollabVersion(workspaceId: string, viewId: string, collabType: Types, versionId: string) Promise~EncodedCollab~
    }

    class AFClientService {
      +getCollabHistory(workspaceId: string, viewId: string, since: Date) Promise~CollabVersionRecord[]~
      +previewCollabVersion(workspaceId: string, viewId: string, versionId: string, collabType: Types) Promise~Uint8Array~
      +createCollabVersion(workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) Promise~string~
      +deleteCollabVersion(workspaceId: string, viewId: string, versionId: string) Promise~void~
      +revertCollabVersion(workspaceId: string, viewId: string, collabType: Types, versionId: string) Promise~EncodedCollab~
    }

    CollabHistoryService <|.. AFClientService

    class AppContextType {
      +getCollabHistory(viewId: string) Promise~CollabVersionRecord[]~
      +previewCollabVersion(viewId: string, versionId: string) Promise~YDoc~
      +revertCollabVersion(viewId: string, versionId: string) Promise~void~
      +loadMentionableUsers() Promise~MentionablePerson[]~
    }

    class BusinessInternalContextType {
      +getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
      +previewCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~YDoc~
      +revertCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~void~
    }

    class SyncInternalContextType {
      +revertCollabVersion(viewId: string, version: string) Promise~void~
    }

    class useViewOperations {
      +getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
      +previewCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~YDoc~
    }

    class AppBusinessLayer {
      +getCollabHistory(viewId: string, since: Date) Promise~CollabVersionRecord[]~
      +revertCollabVersion(viewId: string, versionId: string, collabType: Types) Promise~void~
    }

    AppBusinessLayer --> useViewOperations : uses
    AppBusinessLayer --> SyncInternalContextType : uses
    AppContextType --> AppBusinessLayer : provided_by

    class DocumentHistoryModal {
      +boolean open
      +string viewId
      +ViewIcon icon
      +CollabVersionRecord[] versions
      +string selectedVersionId
      +YDoc activeDoc
      +boolean isRestoring
      +render()
      +refreshVersions() Promise~CollabVersionRecord[]~
      +handleRestore() Promise~void~
    }

    class VersionList {
      +CollabVersionRecord[] versions
      +string selectedVersionId
      +boolean isPro
      +string dateFilter
      +boolean onlyShowMine
      +onSelect(versionId: string) void
      +onDateFilterChange(filter: string) void
      +onOnlyShowMineChange(onlyShowMine: boolean) void
      +onRestoreClicked() void
    }

    class MoreActions {
      +boolean open
      +boolean historyOpen
      +handleOpenHistory() void
    }

    class MoreActionsContent {
      +onOpenHistory() void
      +itemClicked() void
    }

    MoreActions --> MoreActionsContent : renders
    MoreActions --> DocumentHistoryModal : toggles_open
    DocumentHistoryModal --> VersionList : renders
    DocumentHistoryModal --> AppContextType : uses_handlers
    AppContextType --> AFClientService : delegates_collab_calls
Loading

File-Level Changes

Change Details Files
Add document version history modal UI and integrate it into the page More Actions menu for document views.
  • Extend MoreActionsContent to accept an onOpenHistory callback and render a Version History menu item for document layouts, separated by a dropdown separator.
  • Update MoreActions to manage history modal open state, reset it on view changes, and render DocumentHistoryModal when the current view is a document.
  • Implement DocumentHistoryModal to fetch, filter, and display collab versions, preview a selected version in a read-only Editor via Y.Doc, and trigger restore via revertCollabVersion.
  • Implement VersionList sidebar to show versions as a timeline with filters (date ranges, only your edits), restore button, and upgrade hint for non-pro users.
src/components/app/header/MoreActionsContent.tsx
src/components/app/header/MoreActions.tsx
src/components/document/history/DocumentHistoryModal.tsx
src/components/document/history/DocumentHistoryVersionList.tsx
Expose collaboration history operations (list, preview, revert) through services, hooks, and internal contexts.
  • Fix useViewOperations to read sharedRoot from the Yjs doc map and add getCollabHistory and previewCollabVersion hooks that call the AFClientService.
  • Wire getCollabHistory and revertCollabVersion from SyncInternal and ViewOperations through AppBusinessLayer and BusinessInternalContext into AppContext/useAppHandlers.
  • Extend CollabHistoryService and AFClientService to add previewCollabVersion and to return editor IDs from getCollabVersions.
  • Update collab-version types to drop snapshot from CollabVersionRecord and ensure editors are included.
  • Make revertCollabVersion in SyncInternalContextType return a Promise to match async usage in the modal.
src/components/app/hooks/useViewOperations.ts
src/components/app/layers/AppBusinessLayer.tsx
src/components/app/app.hooks.tsx
src/components/app/contexts/BusinessInternalContext.ts
src/application/services/js-services/http/http_api.ts
src/application/services/js-services/index.ts
src/application/collab-version.type.ts
src/components/app/contexts/SyncInternalContext.ts
src/application/services/services.type.ts
src/application/services/js-services/history.ts
Polish shared UI components used by the new history UI.
  • Adjust button sizing to use padding-based height instead of fixed h-* utilities for sm/default/lg/xl variants to better fit within the modal layout.
  • Add a border to DropdownMenuContent and tweak item gap spacing for more compact menus.
  • Introduce crown and info SVG icons for use in the version history upgrade banner and future tooltips.
src/components/ui/button.tsx
src/components/ui/dropdown-menu.tsx
src/assets/icons/crown.svg
src/assets/icons/info.svg

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions
Copy link

github-actions bot commented Dec 12, 2025

🥷 Ninja i18n – 🛎️ Translations need to be updated

Project /project.inlang

lint rule new reports level link
Missing translation 336 warning contribute (via Fink 🐦)

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

  • The new previewCollabVersion path isn’t actually wired through the context: useViewOperations exposes it, but AppBusinessLayer only pulls getCollabHistory, so useAppHandlers().previewCollabVersion will be undefined and DocumentHistoryModal's preview logic will silently no-op; consider passing previewCollabVersion through AppBusinessLayer and BusinessInternalContext the same way as getCollabHistory.
  • In DocumentHistoryModal’s restore handler you call void revertCollabVersion(viewId, selectedVersionId) but then immediately refresh versions; since revertCollabVersion is async, you likely want to await it so the history list reflects the restored state reliably.
  • The authorMap prop computed in DocumentHistoryModal and passed into VersionList is not used anywhere in DocumentHistoryVersionList.tsx; either use it to surface author info in the UI or remove it from both components to avoid dead code.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `previewCollabVersion` path isn’t actually wired through the context: `useViewOperations` exposes it, but `AppBusinessLayer` only pulls `getCollabHistory`, so `useAppHandlers().previewCollabVersion` will be `undefined` and `DocumentHistoryModal`'s preview logic will silently no-op; consider passing `previewCollabVersion` through `AppBusinessLayer` and `BusinessInternalContext` the same way as `getCollabHistory`.
- In `DocumentHistoryModal`’s restore handler you call `void revertCollabVersion(viewId, selectedVersionId)` but then immediately refresh versions; since `revertCollabVersion` is async, you likely want to `await` it so the history list reflects the restored state reliably.
- The `authorMap` prop computed in `DocumentHistoryModal` and passed into `VersionList` is not used anywhere in `DocumentHistoryVersionList.tsx`; either use it to surface author info in the UI or remove it from both components to avoid dead code.

## Individual Comments

### Comment 1
<location> `src/components/document/history/DocumentHistoryModal.tsx:83-84` </location>
<code_context>
+      return true;
+    });
+
+    if (filtered.length > 0 && !filtered.some((version) => version.versionId === selectedVersionId)) {
+      setSelectedVersionId(filtered[0].versionId);
+    }
+
</code_context>

<issue_to_address>
**issue (bug_risk):** Avoid calling `setSelectedVersionId` inside `useMemo` to prevent state updates during render.

Calling `setSelectedVersionId` here introduces a side effect during render, which can trigger React warnings (`Cannot update a component while rendering a different component`) and cause subtle bugs. Move this logic into a `useEffect` keyed on `visibleVersions`/`selectedVersionId`, or compute a local `resolvedSelectedVersionId` instead of updating state inside `useMemo`.
</issue_to_address>

### Comment 2
<location> `src/components/document/history/DocumentHistoryModal.tsx:191-198` </location>
<code_context>
+
+  useEffect(() => {
+    void (async () => {
+      const cachedDoc = previewYDocRef.current.get(selectedVersionId);
+
+      if (cachedDoc) {
+        setActiveDoc(cachedDoc);
+        return;
+      }
+
+      if (!viewId || !previewCollabVersion) {
+        return;
+      }
</code_context>

<issue_to_address>
**issue (bug_risk):** Guard the preview effect when no version is selected to avoid invalid preview calls.

If `selectedVersionId` can be an empty string, this effect may still call `previewCollabVersion(viewId, '')`, resulting in invalid requests and noisy errors. Consider an early return, e.g. `if (!selectedVersionId) return;`, before using it.
</issue_to_address>

### Comment 3
<location> `src/components/document/history/DocumentHistoryModal.tsx:152-156` </location>
<code_context>
+    setIsRestoring(true);
+    setError(null);
+    try {
+      void revertCollabVersion(viewId, selectedVersionId);
+      previewYDocRef.current.clear();
+      setActiveDoc(null);
+
+      const updatedVersions = await refreshVersions();
+
+      if (updatedVersions.length > 0) {
</code_context>

<issue_to_address>
**issue (bug_risk):** `revertCollabVersion` is not awaited, which can cause race conditions with the subsequent refresh.

Because `revertCollabVersion` is intentionally ignored with `void`, `refreshVersions()` may complete before the revert finishes, so the updated list can still reflect the old state. Awaiting `revertCollabVersion(viewId, selectedVersionId)` would ensure the history refresh happens only after the revert completes and that `isRestoring` covers the full restore operation.
</issue_to_address>

### Comment 4
<location> `src/components/app/layers/AppBusinessLayer.tsx:260-261` </location>
<code_context>
     awarenessMap,
     getViewIdFromDatabaseId,
     getViewReadOnlyStatus,
+    getCollabHistory,
+    previewCollabVersion,
   };
</code_context>

<issue_to_address>
**issue (bug_risk):** `previewCollabVersion` is not exposed through the business layer, so the modal cannot use it.

The modal currently expects `previewCollabVersion` from `useAppHandlers`, but `AppBusinessLayer` only injects `getCollabHistory` and `revertCollabVersion` into the context. This means `previewCollabVersion` will be `undefined` and previews will never load. Please pass `previewCollabVersion` through from `useViewOperations` into the context and add it to `AppContextType`.
</issue_to_address>

### Comment 5
<location> `src/components/app/app.hooks.tsx:98` </location>
<code_context>
   getViewIdFromDatabaseId?: (databaseId: string) => Promise<string | null>;
   loadMentionableUsers?: () => Promise<MentionablePerson[]>;
+  getCollabHistory?: (viewId: string) => Promise<CollabVersionRecord[]>;
+  previewCollabVersion?: (viewId: string, versionId: string) => Promise<YDoc>;
+  revertCollabVersion?: (viewId: string, versionId: string) => Promise<void>;
 }
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Align `previewCollabVersion` signatures across hooks, context, and services to avoid type and runtime inconsistencies.

Here `previewCollabVersion` is declared as `(viewId, versionId) => Promise<YDoc>`, but `useViewOperations.previewCollabVersion` expects `(viewId, versionId, collabType: Types)` and only returns a `Y.Doc` when `collabType === Types.Document` (otherwise `undefined`). This discrepancy can cause incorrect calls and hides the need to pass `collabType`. Consider either:
- Adding a higher-level helper that always uses `Types.Document` and returns `Promise<YDoc>`, and wiring that through the UI; or
- Updating the context/service types to include `collabType` and the correct return union so all layers share the same contract.

Suggested implementation:

```typescript
import { AppBusinessLayer } from './layers/AppBusinessLayer';
import { AppSyncLayer } from './layers/AppSyncLayer';
import { CollabVersionRecord } from '@/application/collab-version.type';
import { Types } from '@/application/collab.types';

```

```typescript
  loadMentionableUsers?: () => Promise<MentionablePerson[]>;
  getCollabHistory?: (viewId: string) => Promise<CollabVersionRecord[]>;
  previewCollabVersion?: (
    viewId: string,
    versionId: string,
    collabType: Types,
  ) => Promise<YDoc | undefined>;
  revertCollabVersion?: (viewId: string, versionId: string) => Promise<void>;
}

```

1. Ensure the `Types` import path is correct for your codebase; replace `'@/application/collab.types'` with the actual module that exports the `Types` enum used by `useViewOperations.previewCollabVersion`.
2. Update the implementation of `previewCollabVersion` in the AppContext provider (where `context.previewCollabVersion` is defined) so its function signature matches the updated `AppContextType` and forwards `collabType` to `useViewOperations.previewCollabVersion`.
3. Fix any call sites of `previewCollabVersion` that currently pass only `(viewId, versionId)` to also pass `collabType`, or introduce a higher-level helper (e.g. `previewDocumentCollabVersion(viewId, versionId)`) that always uses `Types.Document` and returns `Promise<YDoc>` if that's the desired UI contract.
</issue_to_address>

### Comment 6
<location> `src/components/document/history/DocumentHistoryModal.tsx:55` </location>
<code_context>
+  const [activeDoc, setActiveDoc] = useState<Y.Doc | null>(null);
+  const [isRestoring, setIsRestoring] = useState(false);
+
+  const visibleVersions = useMemo(() => {
+    let filtered = [...versions];
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider keeping the `visibleVersions` useMemo pure and moving the selection-sync side effect into a dedicated useEffect to make the data flow clearer.

The `visibleVersions` `useMemo` doing a `setSelectedVersionId` is the one spot that meaningfully increases complexity and makes the data flow harder to reason about.

You can keep all behavior while making `useMemo` pure by moving the selection-sync logic into a separate `useEffect`:

```ts
const visibleVersions = useMemo(() => {
  let filtered = [...versions];

  if (onlyShowMine && currentUser) {
    filtered = filtered.filter((version) =>
      version.editors.some((editor) => editor.toString() === currentUser.uid)
    );
  }

  const now = new Date();

  filtered = filtered.filter((version) => {
    if (dateFilter === 'all') return true;

    const diffTime = Math.abs(now.getTime() - version.createdAt.getTime());
    const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));

    if (dateFilter === 'last7Days') return diffDays <= 7;
    if (dateFilter === 'last30Days') return diffDays <= 30;
    if (dateFilter === 'last60Days') return diffDays <= 60;

    return true;
  });

  return filtered;
}, [versions, onlyShowMine, currentUser, dateFilter]);
```

Then add an effect that mirrors the existing behavior of picking the first visible version when the current selection is not present:

```ts
useEffect(() => {
  if (
    visibleVersions.length > 0 &&
    !visibleVersions.some((version) => version.versionId === selectedVersionId)
  ) {
    setSelectedVersionId(visibleVersions[0].versionId);
  }
}, [visibleVersions, selectedVersionId]);
```

This keeps `useMemo` pure, avoids hidden state changes during render, and makes the selection logic explicit and easier to debug.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant